security: apply the Windows ACL protection to the workspace trust store - #250
security: apply the Windows ACL protection to the workspace trust store#250Dhevenddra wants to merge 1 commit into
Conversation
WorkspaceTrustStore.set_trusted protected its state file with a bare
os.chmod(tmp, 0o600). On Windows that is a silent no-op, because os.chmod
only toggles the read-only bit there, so workspace_trust.json kept the broad
ACEs it inherited from its parent directory:
NT AUTHORITY\SYSTEM:(I)(F)
BUILTIN\Administrators:(I)(F)
<user>:(I)(F)
coworker/secrets.py already documents this hazard and solves it in
_restrict_to_user() via icacls /inheritance:r /grant:r, exposed as the
write_private_text() helper. server/run.py uses that helper for the sidecar
auth token, but the trust store did not. Since this file is the allowlist
deciding which workspaces get auto-approved command execution, route it
through the same helper. Afterwards the file carries a single ACE:
<user>:(F)
The two tests meant to catch this asserted st_mode & 0o777 == 0o600, which is
unsatisfiable on Windows. os.stat cannot see ACLs there, so every writable
file reports 0o666, including the correctly protected sidecar token. Replace
both with a shared assert_user_only() that checks mode bits on POSIX and the
DACL on Windows.
rajpratham1
left a comment
There was a problem hiding this comment.
This pull request improves the security of the workspace trust store by replacing the custom temporary file and chmod logic with the shared write_private_text helper. This ensures the trust store receives the same atomic write behavior and platform-specific file protection already used for sensitive secrets, including proper ACL restrictions on Windows where os.chmod(0o600) alone is insufficient. The test suite is updated with a platform-aware helper that validates file protection using POSIX mode bits on Unix-like systems and ACL inspection on Windows, with existing tests updated to use the shared assertion. Based on the visible changes, the implementation is consistent with the existing security model and no blocking issues are apparent.
Problem
WorkspaceTrustStore.set_trusted()protectedworkspace_trust.jsonwith a bareos.chmod(tmp, 0o600). On Windows that is a silent no-op, becauseos.chmodthere onlytoggles the read-only attribute. The file therefore keeps whatever ACEs it inherits from its
parent directory.
coworker/secrets.py:62-66already documents this exact hazard:It solves the problem in
_restrict_to_user(), exposed as thewrite_private_text()helper.server/run.py:134uses that helper for the sidecar auth token. The trust store did not.This matters because
workspace_trust.jsonis the allowlist that decides which workspaces getauto-approved command execution.
test_workspace_command_trust_controls_live_engineassertsthat trusting a workspace flips
run_shellfromneeds_usertoallowed.Evidence
Measured on Windows 11, comparing the two "user-only" files the app writes:
st_modesidecar-9999.tokenwrite_private_text()0o666<user>:(F)workspace_trust.json(before)os.chmod(0o600)0o666NT AUTHORITY\SYSTEM:(I)(F),BUILTIN\Administrators:(I)(F),<user>:(I)(F)workspace_trust.json(after)write_private_text()0o666<user>:(F)The
(I)flags confirm those ACEs were inherited, so the file was never restricted. That isexactly what the comment in
secrets.pypredicts.On the scope of this, stated plainly: on a single-user machine the extra principals are SYSTEM
and Administrators, which are already privileged, so this is not a straightforward privilege
escalation. It matters on shared, domain-joined or managed machines, where
BUILTIN\Administratorscan include accounts the user does not personally control. It alsomatters as a plain correctness defect, because the code claims a protection it does not deliver
on one of the two shipped desktop platforms.
Why the existing tests did not catch it
Both "user-only" tests assert a POSIX bitmask:
On Windows
os.statderivesst_modefrom the read-only attribute alone and has no view ofACLs, so every writable file reports
0o666. That assertion is unsatisfiable on Windows evenwhen the file is correctly protected: it fails for the properly restricted sidecar token too. It
cannot tell a locked-down file apart from a world-readable one.
I replaced it with a shared
assert_user_only()intests/conftest.py, which checks mode bitson POSIX and the DACL on Windows.
Verification
Windows 11, Python 3.11.9:
test_config(trust store)test_server(token)The middle row is the useful one. With only the assertion corrected, the new check passes the
genuinely protected token file and still fails the trust store, which is what shows it
discriminates rather than just passing everything.
Full suite on Windows went from
9 failed, 880 passedto7 failed, 882 passed. The remaining7 are pre-existing Windows failures unrelated to this change: four in
test_slack_relayplus onein
test_github_installs(all timeouts),test_workspace_command_trust_controls_live_enginehitting
WinError 32, andtest_ui_refresh_e2e. I am happy to file those separately. The relaytimeouts in particular have a clean root cause I can write up.
Linux, Python 3.12, matching CI, run in Docker: both target tests pass.
Notes for review
Interaction with #186: that PR closes the TOCTOU inside
write_private_text(). There is no fileoverlap with this PR, since it touches
secrets.pyand this one touchesworkspace_trust.py.Routing the trust store through the shared helper means it picks up that fix automatically once
#186 lands, rather than needing a second patch.
One deliberate behaviour change: the temp file name goes from
.{name}.{pid}.tmpto{name}.tmp, because that is whatwrite_private_text()uses. This drops pid-scoping forconcurrent writers. I judged consistency with the existing helper, and with the more sensitive
token file that already accepts this, to be worth more than pid-scoping for a file written by a
single desktop process. I am happy to keep the pid-scoped name and call
_restrict_to_user()directly instead if you would prefer.
write_private_text()also chmods the parent directory to0700on POSIX.state_dir()isalready
0700via the SecretStore, so that is a no-op in practice.Parsing
icaclsoutput inside a test is not elegant. The alternative is apywin32dependencyfor
win32security. Sincesecrets.py:80already shells out toicacls, this at least staysconsistent with existing practice, and it is easy to swap if you would rather take the
dependency.
Context
I found this while running the test suite on Windows. CI is
ubuntu-latestonly, so this classof defect cannot currently be caught upstream. I would be glad to follow up with a Windows leg on
the CI matrix once the handful of pre-existing Windows failures are cleared.